/*
 * This file is part of LangMan.
 *
 * LangMan is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 2 of the License, or
 * (at your option) any later version.
 *
 * LangMan is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with LangMan.  If not, see <http://www.gnu.org/licenses/>.
 */

package langman.util.doping;

import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.Multimap;
import org.reflections.Reflections;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;

public class Context {
    private final Map<Class<?>, Object> singletons = new WeakHashMap<>();
    private final Reflections reflections;
    private Map<Class<?>, Class<?>> instanstiableTypeCache = new HashMap<>();
    private Map<Class<?>, Constructor<?>> constructorCache = new HashMap<>();
    private Multimap<Class<?>, Method> methodsToCallCache = ArrayListMultimap.create();

    public Context(String packageName) {
        reflections = new Reflections(packageName);
    }

    private static Collection<Method> getMethodsToCall(Class<?> type) {
        Collection<Method> methodsToCall = new ArrayList<>();
        Method[] methods = type.getMethods();
        for (Method method : methods) {
            if (method.isAnnotationPresent(Inject.class)) {
                if (!method.isAccessible()) {
                    method.setAccessible(true);
                }
                methodsToCall.add(method);
            }
        }
        return methodsToCall;
    }

    public static boolean canBeInstantiated(Class<?> type) {
        boolean isNoInterfaceType = !type.isInterface();
        boolean isNoAbstractType = !Modifier.isAbstract(type.getModifiers());

        return isNoAbstractType && isNoInterfaceType;
    }

    public <T> T getBean(Class<T> type) {
        try {
            if (type.equals(Context.class)) {
                return (T) this;
            }

            Class<T> typeToInstantiate = getTypeToInstantiate(type);

            return createBeanFromInstantiatableType(typeToInstantiate);
        } catch (Throwable e) {
            throw new BeanCreationException("Can't create bean of type " + type, e);
        }
    }

    private <T> T createBeanFromInstantiatableType(Class<T> type) {
        if (singletons.containsKey(type)) {
            return (T) singletons.get(type);
        }

        Constructor<?> constructorToUse = getConstructor(type);

        Object beanInstance;
        Class<?>[] parameterTypes = constructorToUse.getParameterTypes();
        if (parameterTypes.length == 0) {
            beanInstance = createBeanInstance(type, constructorToUse);
        } else {
            Object[] parameters = createParameters(parameterTypes);
            beanInstance = createBeanInstance(type, constructorToUse, parameters);
        }

        callAnnotatedMethods(type, (T) beanInstance);

        return (T) beanInstance;
    }

    private <T> Class<T> getTypeToInstantiate(Class<T> type) {
        if (instanstiableTypeCache.containsKey(type)) {
            return (Class<T>) instanstiableTypeCache.get(type);
        }

        Class<T> typeToInstantiate = null;
        if (canBeInstantiated(type)) {
            typeToInstantiate = type;
        } else {
            Set<Class<? extends T>> subTypes = reflections.getSubTypesOf(type);

            boolean foundOne = false;
            for (Class<? extends T> subType : subTypes) {
                if (canBeInstantiated(subType)) {
                    if (foundOne) {
                        throw new IllegalStateException("There is more than one implementor or subtype of " + type);
                    }
                    typeToInstantiate = (Class<T>) subType;
                    foundOne = true;
                }
            }
        }

        instanstiableTypeCache.put(type, typeToInstantiate);
        return typeToInstantiate;
    }

    private Object[] createParameters(Class<?>... parameterTypes) {
        Object[] parameters = new Object[parameterTypes.length];

        for (int i = 0; i < parameterTypes.length; i++) {
            Class<?> parameterType = parameterTypes[i];
            parameters[i] = getBean(parameterType);
        }
        return parameters;
    }

    private <T> Object createBeanInstance(Class<T> type, Constructor<?> constructorToUse, Object... parameters) {
        Object beanInstance;
        try {
            beanInstance = constructorToUse.newInstance(parameters);

            if (type.isAnnotationPresent(SingletonBean.class)) {
                singletons.put(type, beanInstance);
            }

        } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
            throw new IllegalStateException("Creation of bean with type " + type + " failed. Reason: " + e.getMessage(), e);
        }
        return beanInstance;
    }

    private <T> void callAnnotatedMethods(Class<T> type, T bean) {
        Collection<Method> methodsToCall;

        if (methodsToCallCache.containsKey(type)) {
            methodsToCall = methodsToCallCache.get(type);
        } else {
            methodsToCall = getMethodsToCall(type);
            methodsToCallCache.putAll(type, methodsToCall);
        }

        for (Method method : methodsToCall) {
            callMethod(type, bean, method);
        }
    }

    private <T> void callMethod(Class<T> type, T bean, Method method) {
        Class<?>[] parameterTypes = method.getParameterTypes();
        Object[] parameters = createParameters(parameterTypes);
        try {
            method.invoke(bean, parameters);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new IllegalStateException("Creation of bean with type " + type + " failed. Error during call of method " + method + ". Reason: " + e.getMessage(), e);
        }
    }

    private <T> Constructor<?> getConstructor(Class<T> type) {
        if (constructorCache.containsKey(type)) {
            return constructorCache.get(type);
        }

        Constructor<?> constructorToUse = null;

        Constructor<?>[] constructors = type.getDeclaredConstructors();
        for (Constructor<?> constructor : constructors) {
            boolean hasNoParameters = constructor.getParameterTypes().length == 0;
            if (hasNoParameters) {
                constructorToUse = constructor;
                break;
            }

            if (constructor.isAnnotationPresent(Inject.class)) {
                constructorToUse = constructor;
                break;
            }
        }

        if (constructorToUse == null) {
            throw new IllegalArgumentException("Type " + type + " has no empty constructor or constructor annotated with @Inject !");
        }

        if (!constructorToUse.isAccessible()) {
            constructorToUse.setAccessible(true);
        }

        constructorCache.put(type, constructorToUse);

        return constructorToUse;
    }

    public List<Object> getInstantiatedSingletons() {
        return Collections.unmodifiableList(new ArrayList<>(singletons.values()));
    }

    public <T> List<T> getBeansOfType(Class<T> type) {
        Set<Class<? extends T>> beanTypes = reflections.getSubTypesOf(type);
        Set<Class<? extends T>> filteredBeans = filterBeans(beanTypes);

        List<T> beansOfType = new ArrayList<>();
        for (Class<? extends T> beanType : filteredBeans) {
            if (canBeInstantiated(beanType)) {
                T bean = createBeanFromInstantiatableType(beanType);
                beansOfType.add(bean);
            }
        }

        return beansOfType;
    }

    private static <T> Set<Class<? extends T>> filterBeans(Set<Class<? extends T>> beanTypes) {
        Set<Class<? extends T>> filteredBeans = new HashSet<>(beanTypes);

        for (Class<? extends T> beanType : beanTypes) {
            boolean isBean = beanType.getAnnotation(Bean.class) != null;
            boolean isSingletonBean = beanType.getAnnotation(SingletonBean.class) != null;

            if (!(isBean || isSingletonBean)) {
                filteredBeans.remove(beanType);
            }
        }
        return filteredBeans;
    }

    public <T> Set<Class<? extends T>> getBeanClassesOfType(Class<T> clazz) {
        Set<Class<? extends T>> subtypes = reflections.getSubTypesOf(clazz);
        return filterBeans(subtypes);
    }

    public <T> void registerSingleton(Class<T> messagingClientClass, T client) {
        singletons.put(messagingClientClass, client);
    }
}